feat(python-sdk): add LLMGeneratorProtocol for framework-agnostic model injection - #100
feat(python-sdk): add LLMGeneratorProtocol for framework-agnostic model injection#100adnanrhussain wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a framework-agnostic LLM injection interface to the Python SDK so external evaluation frameworks can supply their own async text-generation backend, while keeping the existing LangChain-based path as the default.
Changes:
- Introduces
LLMGeneratorProtocol,LLMResponse, andGenerateConfig, and re-exports them from the SDK package. - Updates
BaseEvaluatorto optionally use an injectedllm_providerfor prompt execution and JSON parsing (LangChain path remains the default). - Bumps the Python SDK version and expands
.gitignorefor common build/test artifacts and logs.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
sdks/python/src/learning_commons_evaluators/schemas/llm_provider.py |
Adds protocol + response/config types for injected LLM generation. |
sdks/python/src/learning_commons_evaluators/evaluators/base.py |
Adds injected-provider execution path and _strip_json_fences helper. |
sdks/python/src/learning_commons_evaluators/schemas/__init__.py |
Re-exports new LLM provider types from schemas. |
sdks/python/src/learning_commons_evaluators/__init__.py |
Re-exports new types from the top-level package __all__. |
sdks/python/tests/schemas/test_llm_provider.py |
Adds unit tests for the new protocol/types and package exports. |
sdks/python/pyproject.toml |
Bumps SDK version to 0.3.0. |
.gitignore |
Ignores build artifacts, caches, and logs/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try: | ||
| response: LLMResponse = await provider.generate( | ||
| system=system_str, | ||
| human=human_str, | ||
| config=GenerateConfig(temperature=prompt_settings.temperature), | ||
| ) |
There was a problem hiding this comment.
The protocol pattern deliberately abstracts the model selection out of the SDK - the adapter is constructed with a specific model and handles all steps.
If needed we can support a list of providers later.
d4f80a9 to
ca64de2
Compare
…el injection Introduces three types to sdks/python: - LLMGeneratorProtocol (typing.Protocol) — structural interface for injecting any LLM backend into evaluators without a hard framework dependency - LLMResponse (NamedTuple) — structured response aligned with OTel GenAI semconv (content, model, input_tokens, output_tokens) - GenerateConfig (dataclass) — temperature and max_tokens passthrough Refactors BaseEvaluator.execute_prompt_chain_step to accept an optional llm_provider: LLMGeneratorProtocol. When set, the protocol path formats the LangChain template to extract system/human strings, delegates the LLM call to the injected provider, and parses the JSON response via Pydantic directly. The existing LangChain path is unchanged and remains the default. Also improves _strip_json_fences to use JSONDecoder.raw_decode, correctly handling trailing prose and multiple JSON objects in LLM responses. Also adds *.egg-info/, dist/, build/, logs/ to root .gitignore.
…_step Adds TestExecutePromptChainStepProtocolPath (13 tests) covering the llm_provider injection path in BaseEvaluator.execute_prompt_chain_step: - Raw string return when parser_output_type=None - Clean JSON parse - Markdown fence stripping - Trailing prose stripping (JSON followed by explanation text) - Leading prose stripping (prose before JSON) - json_dict_normalizer path - Non-dict JSON raises OutputValidationError on normalizer path - Malformed JSON raises OutputValidationError - Schema mismatch raises OutputValidationError - Token usage recorded in step extras and total_token_usage - Token usage absent when LLMResponse has None tokens - Provider RuntimeError wrapped as APIError - EvaluatorError from provider re-raised unchanged - KeyboardInterrupt from provider propagated
- Revert version bump to 0.2.0 (release-please handles this on merge) - Add model field to GenerateConfig so adapters can see which model the evaluator expects without reaching into prompt_settings - Move template.aformat_messages() inside try block so missing template variables raise EvaluatorError rather than bare KeyError - Add ValueError when human_str is empty; DEBUG log when system_str is empty - Extract _parse_json_output() helper to deduplicate JSON parsing and error wrapping between the protocol and LangChain paths - Add tests: assert adapter.generate() called with correct system/human, human-only template passes empty system string, missing variable raises EvaluatorError
7d75e0d to
491f581
Compare
czi-fsisenda
left a comment
There was a problem hiding this comment.
Looks good!
A few questions in comments.
| """LLM provider protocol and associated types for framework-agnostic model injection. | ||
|
|
||
| These types define the interface that evaluation frameworks (Inspect AI, Braintrust, | ||
| Arize/Phoenix, Langfuse, etc.) implement to provide their own model execution to the SDK. | ||
|
|
||
| ``LLMGeneratorProtocol`` is a structural protocol (``typing.Protocol``) — integration | ||
| packages do not need to import or inherit from it. Any class with the correct ``generate`` | ||
| signature satisfies the protocol automatically for static type checkers. | ||
|
|
||
| Response fields are aligned with OpenTelemetry GenAI semantic conventions: | ||
| https://opentelemetry.io/docs/specs/semconv/gen-ai/ | ||
| """ |
There was a problem hiding this comment.
Are the names of the types in this file standard / widely used?
| def _strip_json_fences(text: str) -> str: | ||
| """Strip markdown code fences and extract the first valid JSON object or array. |
There was a problem hiding this comment.
Are we anticipating needing this for any LLMGenerator?
| ParsedT = TypeVar("ParsedT", bound=BaseModel) | ||
|
|
||
|
|
||
| def _parse_json_output( |
There was a problem hiding this comment.
Wondering if we could leverage Langchain's parsing tools. Looks good.
| if self._llm_provider is not None: | ||
| # ── Protocol path ───────────────────────────────────────────── | ||
| provider = self._llm_provider |
There was a problem hiding this comment.
I was looking into leveraging the existing prompt settings override to allow a custom generator.
This works though. We can iterate.
Summary
Introduces a framework-agnostic model injection interface to the Python SDK, enabling evaluation frameworks (Inspect AI, Arize, Langfuse, Braintrust) to provide their own LLM backend without the SDK depending on any of them directly.
New types in
sdks/python:LLMGeneratorProtocol(typing.Protocol) — structural interface: one asyncgenerate(*, system, human, config) -> LLMResponsemethodLLMResponse(NamedTuple) — structured response aligned with OTel GenAI semconv:content,model,input_tokens,output_tokensGenerateConfig(dataclass) —temperatureandmax_tokenspassthroughBaseEvaluatorchanges:llm_provider: LLMGeneratorProtocolin__init__Other:
_strip_json_fencesnow usesJSONDecoder.raw_decode— correctly handles trailing prose and multiple JSON objects in LLM responses.gitignoreupdated to cover*.egg-info/,dist/,build/,logs/Test plan
tests/schemas/test_llm_provider.pycover protocol conformance,LLMResponsefield defaults,GenerateConfigpassthrough_strip_json_fenceshandles fenced, prose-wrapped, and trailing-prose responsesIntegration packages
This PR is the base for #integrations PR which adds Inspect AI, Arize, Langfuse, and Braintrust adapters on top of this protocol.
🤖 Generated with Claude Code